Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix maximum update depth exceeded #387

Open
wants to merge 6 commits into
base: master
Choose a base branch
from

Conversation

EdoardoGruppi
Copy link

Hi,
I encountered an issue in my project similar to that described in this open issue link.

When using the code snippet below:

<Spreadsheet
  data={data}
  onChange={(newData: any) => {
    console.log(newData);
    setData(newData);
  }}
/>

Every time I made a change in a cell, I noticed that console.log was being called infinitely.

Upon investigation, I found that the issue lies within the Spreadsheet.tsx file, specifically in the following useEffect:

React.useEffect(() => {
  if (state.model.data !== prevDataRef.current) {
    // Call onChange only if the data changes internally
    if (state.model.data !== props.data) {
      onChange(state.model.data);
    }
  }
  prevDataRef.current = state.model.data;
}, [state.model.data, onChange, props.data]);

The problem arises from the comparisons made inside the useEffect's callback function and within the dependency array. Since we are comparing two non-primitive variables, these inequality comparisons always return true. Consequently, when the value of a cell changes, state.model.data varies, triggering the useEffect. However, the two if conditions always evaluate to true, leading to the invocation of onChange. As onChange sets new props, the useEffect is triggered again, resulting in an infinite loop.

To address this issue, I created a new function in the util.ts file that enables deep comparison of arrays. By using this function within the useEffect body, I ensure that the infinite loop is halted, and onChange is called only once.

// Call on change only if the data change internal
if (state.model.data !== props.data) {
if (!deepArrayComparison(state.model.data, props.data)) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sorry but although this is a working solution for small datasets, for big ones it could be very problematic. The fix should be to make sure the reference isn't different in the first place.

@EdoardoGruppi
Copy link
Author

Hello Iddan,

Thank you for taking the time to review my pull request.

I understand your concerns regarding the potential slowdown for very large datasets. However, in my humble opinion, some slowdowns are preferable than infinite rendering loop. The main problem is that, in the original code, when the two object references are compared (either implicitly in the array of dependencies of the useEffect hook or explicitly within the useEffect's callback body) an endless loop can occur after any change. This is because there is no guarantee the object references will remain the same between renderings.

To address both issues, I have introduced a new update that adds a 'lastUpdateDate' property to the state object. The concept is to keep track of the last time the data within state.model has changed.
This allows us to compare the model inner statuses and execute the desired task as soon as the information in state.model.data varies. Furthermore, this modification works efficiently by replacing all the nested deep comparisons.

Thank you for your attention and consideration.

@@ -132,6 +133,7 @@ export default function reducer(
...state,
model: updateCellValue(state.model, active, cellData),
lastChanged: active,
lastUpdateDate: new Date(),
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this needed?

Copy link
Author

@EdoardoGruppi EdoardoGruppi May 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi Iddan,

First off, it's commonly advised against including objects directly in the dependency array of a useEffect. This is because objects are compared by reference, not by value. What this means is that even if the content of the object hasn't changed, if a new object is created on each render, the effect will still run on every render because the references are different.

So, although comparing object references directly might seem like a good idea, it's actually not recommended.

In the case of react-spreadsheet, the code below can lead to an infinite loop due to shallow comparisons made within the useEffect body and the dependency array.

<Spreadsheet
  data={data}
  onChange={(newData: any) => {
    console.log(newData);
    setData(newData);
  }}
/>
React.useEffect(() => {
  if (state.model.data !== prevDataRef.current) {
    // Call onChange only if the data changes internally
    if (state.model.data !== props.data) {
      onChange(state.model.data);
    }
  }
  prevDataRef.current = state.model.data;
}, [state.model.data, onChange, props.data]);

2024-05-1012-03-19-ezgif com-video-to-gif-converter

To illustrate this point further, you can try changing the second block to:

React.useEffect(() => {
  console.log("Use Effect called");
}, [state.model.data]);

You'll notice that even though the object might appear to be the same instance, its reference changes after every rendering. This causes the console log to be called every time there is a modification.

To summarize:

  1. state.model.data changes its reference every time setCellData is called.
  2. Since the reference changes, the useEffect body is executed.
  3. The first if condition is verified as the reference is always different from the one previously stored.
  4. The second if condition is verified because there is no guarantee props.data has a reference equal to the new state.model.data reference.
  5. The onChange function calls the setData function within the SpreadSheet object in App.tsx.
  6. The loop restarts without an end.

Solution

Adding a new lastUpdateDate property addresses this issue while avoiding resource-intensive deep nested comparisons.

With this addition, we track the last time setCellData is executed. If the data displayed in the UI has a different lastUpdateDate value, the useEffect is called, and the displayed information is updated accordingly.

Copy link

@thsorens thsorens Jun 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React.useEffect(() => {

}, [JSON.stringify(state.model.data)]);

Why not use just a string compare? For the huge datasets, it might be too slow though, but there is other libraries that can handle stringifying of an object more performant. Comparing a string, instead of having to traverse a huge object-graph is way easier.

The lastUpdate date check can probably cause some bad sideeffects as well.

Copy link
Owner

@iddan iddan Jun 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @EdoardoGruppi for the detailed writing. I am unaware that any official React resources do not recommend object reference comparison. The real problem is the array reference changes which I don't see why it should happen more than once per mutation to the data.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@EdoardoGruppi are you using the latest version of the code? The controlled storybook story works correctly and it uses the same code as you posted.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@thsorens in the same spirit of React, I try to avoid unsimple comparisons on data.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @iddan,
thanks for your responses.

I confirm that I am using the latter version of the code. To be 100% sure, I re-did the test by cloning the repo from scratch, but nothing changed. You can find a sandbox at this link to check the results I am getting. To see the logs, you just need to open the console in the embedded CodeSandbox browser.

You're correct that React doesn't explicitly advise against using non-primitive values inside the dependency array. However, many developers recommend avoiding this practice because it can lead to mistakes and unintended re-renders.

I am confident that this is the exact problem with the react-spreadsheet library. In one of my initial commits, I used a function to deep compare objects, which solved the issue. The same error can be highlighted when adding a log inside the outer if statement. The comparison state.model.data !== prevDataRef.current returns true endlessly every time a cell changes its value.

React.useEffect(() => {
  if (state.model.data !== prevDataRef.current) {
      console.log('state.model.data is not equal to prevDataRef.current')
  }
  prevDataRef.current = state.model.data;
}, [state.model.data]);

It's worth mentioning that I later replaced that function to avoid slowdowns with a comparison made on a new lastUpdatedDate property of the state object. I think that using the date is a nice way to solve the issue since: it doesn't add much code or memory occupation, makes the comparison very fast, and provides additional information that may be useful for future features.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@iddan : I also found a different weird behavior in the storybook version. Add some random text in a cell. Put the focus on another cell. Then go back to the cell, type 1 (the number 1). The focus will automatically disappear, so it will not be possible to continue writing in the cell. It might be related to the grid running in storybook, since i cant reproduce it in the "demo" version. (Running chrome)

@sproleee
Copy link

sproleee commented Oct 16, 2024

The fact that you are comparing 2 non-primitives in the useEffect and triggering re-renders at the top level like this means this whole package is dead on arrival. There should be a warning to users to know this is a massive bug before actually using this library.

This thing needs to be torn apart and re-architected from the ground up. If you want to make comparisons like that, it needs to be on lower-level components like the cells themselves (if possible) OR find a way to compare the equality of two primitives.

While @EdoardoGruppi's solution isn't perfect, it's far better than what's happening today.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants